iT邦幫忙

2024 iThome 鐵人賽

DAY 14
1
Software Development

一個好的系統之好維護基本篇 ( 馬克版 )系列 第 14

Day-14: 提升維護性與降低複雜度的好方法之 Domain Model

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240928/20089358rb8TAp7tWL.png


這篇文章將要來談談,目前軟體工程中,我自已覺得幫助很大的一個東西是 Domain Model,接下來將談談實作應用時我們會如何使用,還有搭配什麼東西 ~

什麼是 Domain Model 與為啥用它 ?

根據 《This pattern is part of Patterns of Enterprise Application Architecture》的定義如下 :

An object model of the domain that incorporates both behavior and data.

然後它可以解什麼事情呢 ? 如果說我覺得最有感的應該就是 :

那就是資料庫有某個欄位,如果別人問這個欄位什麼時後會變化呢 ? 你要如何追 ?

然後有了 Domain Model 就可以達到以下的事情 :

所有業務欄位的狀態變化都封裝在這個 model 中,也就是說你看這個 model 就知道什麼情況下會 status 會變成 PAID。

! 注意雖然有感是那樣,但是 domain model 與資料庫 model 不要混在一起

雖然說資料庫欄位和 domain 欄位不一定是強綁定,但是會有關聯,所以如果有用 domain model 的話流程會是 :

  • 看一下 repository ( 等等會說 ) 裡的 db table 與 domain model 的 mapper。
  • 看看 domain model 那個欄位,然後在這個 model 中找找啥時會變化。

然後如果沒有這個機制的話,每個地方都是直接 update 資料這個欄位,你應該會花很多時間找,而且如果這個欄位名很通用,並且 table 也用在很多地方,你應該會幹掉問你問題的人。

簡單的說,用了 domain model 整體的維護性,會增加不少。( 我指複雜業務變動,而不是 query 的業務 )

~小備註~
上面那種每個地方都用 update 啥的,事實上就是 CRUD 的寫法,通常只讓人寫的很快、很直覺,但是問題就在接下來維護的人都會罵乾。

以下為 domain model 的簡單範例,可以注意到一個重點:

這個 model 裡的欄位變化,都只會在這個 model 裡面

class Order {
  private items: Map<string, OrderItem> = new Map();
  private status: OrderStatus = OrderStatus.PENDING;

  constructor(public readonly id: string) {
    if (!id) {
      throw new Error("Order must have a valid ID");
    }
  }

  addProduct(product: Product, quantity: number): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error("Cannot modify an order that is not in PENDING status.");
    }

    const existingItem = this.items.get(product.id);
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.items.set(product.id, new OrderItem(product, quantity));
    }
  }

  removeProduct(productId: string): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error("Cannot modify an order that is not in PENDING status.");
    }

    if (!this.items.has(productId)) {
      throw new Error("Product not found in the order");
    }

    this.items.delete(productId);
  }

  pay(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error("Order can only be paid if it's in PENDING status.");
    }

    this.status = OrderStatus.PAID;
  }

  ship(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new Error("Order must be PAID before it can be shipped.");
    }

    this.status = OrderStatus.SHIPPED;
  }

  cancel(): void {
    if (this.status === OrderStatus.COMPLETED || this.status === OrderStatus.CANCELLED) {
      throw new Error("Cannot cancel a completed or already cancelled order.");
    }

    this.status = OrderStatus.CANCELLED;
  }
}

實務上的使用方式

實務上它通常會搭配 repository 一起使用,也就是說 repository 回傳的就是 domain model,如下範例,我們以底層為 mongoose 為例,然後總共有幾個元件。

  • MongoOrderRepository: 負責產出 domain model 與更新 domain model。
  • OrderMapper: 它會進行 domain model 與 mongoose model 的轉換。
  • Order: Domain Model (內容在上面)。
  • OrderItemDoc、OrderDoc: mongoose model 的 interface。

以下為範例碼,就大概是長這個樣子。

interface OrderItemDoc {
  orderId: string;
  productId: string;
  productName: string;
  productPrice: number;
  quantity: number;
}

enum OrderStatus {
  PENDING = "PENDING",
  PAID = "PAID",
  SHIPPED = "SHIPPED",
  COMPLETED = "COMPLETED",
  CANCELLED = "CANCELLED"
}

interface OrderDoc {
  id: string;
  status: OrderStatus;
}
interface OrderRepository {
  save(order: Order): Promise<void>;
  findAll(): Promise<Order[]>;
}

class MongoOrderRepository implements OrderRepository {
  constructor(
    @InjectModel('Order') private readonly orderModel: Model<OrderModel>,
    @InjectModel('OrderItem') private readonly orderItemModel: Model<OrderItemModel>,
  ) {}

  async save(order: Order): Promise<void> {
    const { orderDoc, orderItemsDocs } = OrderMapper.toPersistence(
      order
    );

    const existingOrder = await this.orderModel.findOne({ id: order.id });
    if (existingOrder) {
      existingOrder.status = orderDoc.status;
      await existingOrder.save();
    } else {
      await orderDoc.save();
    }

    await this.orderItemModel.deleteMany({ orderId: order.id }); 
    await this.orderItemModel.insertMany(orderItemsDocs); 
  }

  async findAll(): Promise<Order[]> {
    const orderDocs = await this.orderModel.aggregate([
      {
        $lookup: {
          from: 'orderitems',
          localField: 'id',
          foreignField: 'orderId',
          as: 'items',
        },
      },
    ]);

    const orders: Order[] = [];

    for (const orderDoc of orderDocs) {
      const order = OrderMapper.toDomain(orderDoc, orderDoc.items);
      orders.push(order);
    }

    return orders;
  }
}
class OrderMapper {
  // 將 MongoDB 的 Order 和 OrderItem 模型轉換為領域模型
  static toDomain(orderDoc: OrderModel, itemDocs: OrderItemModel[]): Order {
    const order = new Order(orderDoc.id);
    
    itemDocs.forEach((itemDoc) => {
      const orderItem = new OrderItem(
        itemDoc.productId,
        itemDoc.productName,
        itemDoc.productPrice,
        itemDoc.quantity
      );
      order.addItem(orderItem);
    });

    return order;
  }

  // 將領域模型轉換為 MongoDB 的模型格式 (保存時使用)
  static toPersistence(
    order: Order
  ): {
    orderDoc: OrderModel;
    orderItemsDocs: OrderItemModel[];
  } {
    const orderDoc = new OrderModel({
      id: order.id,
      status: order.status,
    });

    const orderItemsDocs = order.getItems().map((item) => {
      return new OrderItemModel({
        orderId: order.id,
        productId: item.product.id,
        productName: item.product.name,
        productPrice: item.product.price,
        quantity: item.quantity,
      });
    });

    return { orderDoc, orderItemsDocs };
  }
}

Q & A

1. 那這個 Repository 要在那呼叫 ?

就通常就在 service、usecase 那一層來呼叫。

2. 為什麼要有 Repository,不能直接用 Active Record 呢 ?

先簡單說一下,active reocrd 可能想成就是『 ORM + 業務行為方法合在一起 』,例如下面範例。

  const user = new UserModel({ name: 'Alice', email: 'alice@example.com' });
  user.register();
  await user.save();

但它不太適合複雜的情境。如果是簡單的情況下用 active reocrd 是沒啥問題,但複雜的情境不建議,因為會有以下幾個問題 :

  • 跨表難處理。
  • 資料與 domain 混合在一起,你要 peak 重用性與性能都會碰到問題。
  • 你換成 sql 怎麼辦 ?

所以通常如果是產品類的服務,基本上我 default 都傾向 repository,而不是 active reocrd。

3. 那種情況下,不適合 Domain Model

大部份都是 query,而且其他操作都是 crud 那麼單純。

4. 那 Query 情境下建議怎麼處理 ?

query 的情況下,也一樣要從 repository 拿出 domain model,然後回傳 domain model 的東西嗎 ? 這個會開一篇文章來寫,先簡單說一下不太建議。

5. Domain model 所有欄位的狀態變化都一定要封在裡面嗎 ?

對 ~ 但是我在實務上有碰過例外,我現在也沒啥好解法。

那就是 id。很多情況下我們的 id 都是會 save 完或啥後,db 會自動產生,所以目前除了 id 這個特殊會讓我用 set,其它情況下都一定只能在 domain model 的 method 中進行變化。

雖然有些人可能會說,那就先在 db 建個簡單的 data,然後就有 id 了,但有時後 db 我們也會有那些 require 欄位,所以也不算很好產生。

然後在某個地方維持 global id 也算是個方案,但問題就在於我們需要這樣做嗎 ? 好處是啥 ?

6. 它是 DDD 的 Aggregate 嗎 ?

某些方面我覺得核心可以算是,但 aggregate 還有一些變化,後面會說。

7. Repository 可以回傳非 Domain Model 的東西嗎 ?

這個我先打個問號,後面談到 ddd 與 aggregate 時會來說說 ~


小結

大概在很久以前,我事實上有寫過這篇文章。我還記憶尤新,當初在寫時我發現我完全不知道他是在衝啥小,當純看 PoEAA 寫的我還是不太能理解,但是工作久了以後,碰到很多次我下面說的問題:

30-12 之 Domain Layer - Domain Model ( 未完成版 )

那就是資料庫有某個欄位,如果別人問這個欄位什麼時後會變化呢 ? 你要如何追 ?

總於可以體諒到 domain model 的強大,雖然這只是其它一個好處,但這個真的讓我記得很清楚,而且現在回想起來,我第二份工作時也就有用過,但那時也沒能體會,我在想可能是我大部份的情況都是在寫新的東西吧,到了現在當 teck leader 後,一直要處理 legacy,並且每天都被人問說這個的業務規則後,才能慢慢的體會……


上一篇
Day-13: 契約式設計 ( DBC Design By Contract ) vs 防禦式程式設計( Defensive Programming )
下一篇
Day-15: Domain Model 實務上面對的困境之 DDD Trilemma
系列文
一個好的系統之好維護基本篇 ( 馬克版 )30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言